进阶 创建附件接口:复杂创建逻辑与关联 DTO 的创建
附件(Attachment)模块是内容管理系统中关联关系较复杂的部分。附件创建时需要同时处理其属性项(AttachmentAttribute),而属性项又需要与字典表(DictAttachmentAttribute)进行关联。本文将深入讲解多表嵌套创建的 DTO 设计和 connectOrCreate 查询模式。
数据模型关系
Attachment (附件)
└── AttachmentAttribute[] (附件属性值 - 一对多)
└── DictAttachmentAttribute (字典属性 - 多对一)
text
- Attachment:存储附件基础信息(名称、路径、类型等)
- AttachmentAttribute:存储属性值(如分辨率
1920x1080、格式JPG) - DictAttachmentAttribute:定义属性分类(如"分辨率"、"格式"、"大小")
一个附件可以拥有多个属性,每个属性来自字典表的某个分类,并在关联表中存储具体的值。
文件存储命名规范
上传文件时,建议使用以下命名规则确保唯一性:
/{userId}/{date}/{filename}-{hash}.{ext}
text
示例:
/1/2026-05-19/report-a1b2c3d4.pdf
/1/2026-05-19/report-a1b2c3d4-uuid.pdf (更安全的方案)
text
| 组件 | 说明 |
|---|---|
userId | 用户个人目录 |
date | 按日期划分目录 |
hash | 短哈希串,避免同名文件覆盖 |
UUID | 可选,进一步保证文件名全局唯一 |
ext | 文件原始后缀名 |
DTO 设计
CreateAttachmentDto
// attachment/dto/create-attachment.dto.ts
import {
IsString,
IsInt,
IsEnum,
IsOptional,
ValidateNested,
IsArray,
} from 'class-validator';
import { Type } from 'class-transformer';
import { CreateAttachmentAttributeDto } from './create-attachment-attribute.dto';
export enum AttachmentCategory {
TEXT = 'text',
IMAGE = 'image',
AUDIO = 'audio',
VIDEO = 'video',
}
export enum OssType {
QINIU = 'qiniu',
ALIYUN = 'aliyun',
TENCENT = 'tencent',
}
export class CreateAttachmentDto {
@IsEnum(AttachmentCategory)
category: AttachmentCategory;
@IsEnum(OssType)
@IsOptional()
ossType?: OssType;
@IsString()
name: string;
@IsString()
location: string; // 存储路径(必传)
@IsString()
@IsOptional()
desc?: string;
@IsInt()
userId: number;
@IsArray()
@IsOptional()
@ValidateNested({ each: true })
@Type(() => CreateAttachmentAttributeDto)
attributes?: CreateAttachmentAttributeDto[];
}
typescript
CreateAttachmentAttributeDto
// attachment/dto/create-attachment-attribute.dto.ts
import {
IsInt,
IsString,
IsOptional,
ValidateNested,
ValidateIf,
} from 'class-validator';
import { Type } from 'class-transformer';
import { CreateDictAttachmentAttributeDto } from '../../dict/attachment-attribute/dto/create-dict-attachment-attribute.dto';
export class CreateAttachmentAttributeDto {
@IsInt()
@IsOptional()
attachmentId?: number;
@IsInt()
@IsOptional()
attributeId?: number;
@IsString()
value: string;
@IsString()
@IsOptional()
desc?: string;
@IsOptional()
@ValidateNested()
@Type(() => CreateDictAttachmentAttributeDto)
@ValidateIf((o) => !o.attributeId) // 无 attributeId 时必须传 dict
dict?: CreateDictAttachmentAttributeDto;
}
typescript
条件校验逻辑:
- 如果传了
attributeId,说明属性已在字典表中存在,直接关联即可 - 如果没传
attributeId,则必须传dict对象来创建新的字典属性
// dict/attachment-attribute/dto/create-dict-attachment-attribute.dto.ts
import { IsString, IsOptional } from 'class-validator';
export class CreateDictAttachmentAttributeDto {
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
type?: string;
}
typescript
Service 创建逻辑
核心在于处理 attributes 数组中的嵌套创建——使用 Prisma 的 connectOrCreate 模式,实现"存在则关联,不存在则创建":
// attachment/attachment.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { CreateAttachmentDto } from './dto/create-attachment.dto';
@Injectable()
export class AttachmentService {
constructor(private prisma: PrismaClient) {}
async create(dto: CreateAttachmentDto) {
const { attributes, ...restData } = dto;
const createArr: any[] = [];
if (attributes && Array.isArray(attributes) && attributes.length > 0) {
for (const attribute of attributes) {
const { dict, ...restAttributeData } = attribute;
const dictObject: any = {};
if (dict) {
const { id, name, type } = dict;
// 根据是否有 ID 决定查询条件
const whereCondition = id
? { id }
: { type_name: { type, name } }; // 复合唯一索引查询
dictObject.dictAttribute = {
connectOrCreate: {
where: whereCondition,
create: { name, type },
},
};
}
createArr.push({
...restAttributeData,
...dictObject,
});
}
}
return this.prisma.attachment.create({
data: {
...restData,
attachmentAttributes: {
create: createArr,
},
},
include: {
attachmentAttributes: true,
},
});
}
}
typescript
connectOrCreate 工作流程
attributes: [
{ value: "1920x1080", dict: { type: "resolution", name: "分辨率" } },
{ value: "JPG", dict: { type: "format", name: "格式" } }
]
│
▼
┌─────────────────────────────┐
│ 遍历每个 attribute │
│ ├── 检查 dict 中是否有 ID │
│ │ ├── 有 ID → where: { id } │
│ │ └── 无 ID → where: { type, name } │
│ │ │
│ └── connectOrCreate │
│ ├── 字典表中存在 → 直接关联 │
│ └── 字典表中不存在 → 先创建再关联 │
└─────────────────────────────┘
text
复合唯一索引查询
当 Schema 中定义了 @@unique 约束时:
model DictAttachmentAttribute {
id Int @id @default(autoincrement())
type String
name String
@@unique([type, name])
}
prisma
Prisma Client 会自动生成复合字段查询语法:
// 复合唯一索引查询
await prisma.dictAttachmentAttribute.findUnique({
where: {
type_name: { type: 'resolution', name: '分辨率' },
},
});
typescript
测试用例
场景一:传递 dict 对象创建新属性
POST /attachment
{
"category": "image",
"name": "screenshot.png",
"ossType": "qiniu",
"location": "/1/2026-05-19/screenshot-a1b2c3.png",
"userId": 1,
"attributes": [
{
"value": "1920x1080",
"dict": { "type": "resolution", "name": "分辨率" }
},
{
"value": "PNG",
"dict": { "type": "format", "name": "格式" }
}
]
}
json
场景二:传递已知的 attributeId
POST /attachment
{
"category": "video",
"name": "demo.mp4",
"location": "/1/2026-05-19/demo-d4e5f6.mp4",
"userId": 1,
"attributes": [
{ "attributeId": 9, "value": "00:30:00" },
{ "attributeId": 10, "value": "1080p" }
]
}
json
小结
| 概念 | 要点 |
|---|---|
| DTO 嵌套设计 | CreateAttachmentDto 包含 CreateAttachmentAttributeDto[],后者又包含 CreateDictAttachmentAttributeDto |
| 条件校验 | 使用 @ValidateIf 实现"有 ID 走关联,无 ID 走创建"的逻辑分支 |
| connectOrCreate | Prisma 提供的原子操作,存在则关联、不存在则创建,避免手动查询+插入的两步操作 |
| 复合唯一索引 | @@unique([type, name]) 生成 type_name 复合查询字段 |
| 文件命名 | /{userId}/{date}/{filename}-{hash}.{ext} 确保唯一性 |
↑